'use strict';

import query from 'querystring';
import url from 'url';
import { BitcoreLib as Bitcore } from 'crypto-wallet-core';
import superagent from 'superagent';
import dfltTrustedKeys from '../util/JsonPaymentProtocolKeys';
import { Errors } from './errors';

const sha256 = Bitcore.crypto.Hash.sha256;
const BN = Bitcore.crypto.BN;

const MAX_FEE_PER_KB = {
  btc: 10000 * 1000, // 10k sat/b
  bch: 10000 * 1000, // 10k sat/b
  eth: 1000000000000, // 1000 Gwei
  matic: 1000000000000, // 1000 Gwei
  arb: 1000000000000, // 1000 Gwei
  base: 1000000000000, // 1000 Gwei
  op: 1000000000000, // 1000 Gwei
  xrp: 1000000000000,
  doge: 10000 * 1000, // 10k sat/b
  ltc: 10000 * 1000, // 10k sat/b
  sol: 15000
};

// PayPro Network Map
export enum NetworkMap {
  main = 'livenet',
  test = 'testnet',
  regtest = 'regtest'
};

export class PayProV2 {
  static options: { headers?: any; args?: string; agent?: boolean } = {
    headers: {},
    args: '',
    agent: false
  };
  static request = superagent;
  static trustedKeys = dfltTrustedKeys;

  constructor(requestOptions = {}, trustedKeys = dfltTrustedKeys) {
    PayProV2.options = Object.assign({}, { agent: false }, requestOptions);
    PayProV2.trustedKeys = trustedKeys;
    if (!PayProV2.trustedKeys || !Object.keys(PayProV2.trustedKeys).length) {
      throw new Error('Invalid constructor, no trusted keys added to agent');
    }
  }

  /**
   * Internal method for making requests asynchronously
   * @param {Object} options
   * @return {Promise<Object{rawBody: String, headers: Object}>}
   * @private
   */
  static async _asyncRequest(
    options
  ): Promise<{ rawBody: string; headers: object }> {
    return new Promise((resolve, reject) => {
      const requestOptions = Object.assign({}, PayProV2.options, options);

      // Copy headers directly as they're objects
      requestOptions.headers = Object.assign(
        {},
        PayProV2.options.headers,
        options.headers
      );

      const r = this.request[requestOptions.method](requestOptions.url);
      for (const [k, v] of Object.entries(requestOptions.headers || {})) {
        if (v) r.set(k, v);
      }
      r.agent(requestOptions.agent);

      if (requestOptions.args) {
        if (requestOptions.method == 'post' || requestOptions.method == 'put') {
          r.send(requestOptions.args);
        } else {
          r.query(requestOptions.args);
        }
      }

      r.end((err, res) => {
        if (err) {
          if (res && res.statusCode !== 200) {
            // some know codes
            if (
              (res.statusCode == 400 || res.statusCode == 422) &&
              res.body &&
              res.body.msg
            ) {
              return reject(this.getError(res.body.msg));
            } else if (res.statusCode == 404) {
              return reject(new Errors.INVOICE_NOT_AVAILABLE());
            } else if (res.statusCode == 504) {
              return reject(new Errors.REQUEST_TIMEOUT());
            } else if (res.statusCode == 500 && res.body && res.body.msg) {
              return reject(new Error(res.body.msg));
            } else {
              return reject(new Errors.INVALID_REQUEST());
            }
          }
          return reject(err);
        }
        return resolve({
          rawBody: res.text,
          headers: res.headers
        });
      });
    });
  }

  static getError(errMsg: string): Error {
    switch (true) {
      case errMsg.includes('Invoice no longer accepting payments'):
        return new Errors.INVOICE_EXPIRED();
      case errMsg.includes('We were unable to parse your payment.'):
        return new Errors.UNABLE_TO_PARSE_PAYMENT();
      case errMsg.includes('Request must include exactly one'):
        return new Errors.NO_TRASACTION();
      case errMsg.includes('Your transaction was an in an invalid format'):
        return new Errors.INVALID_TX_FORMAT();
      case errMsg.includes('We were unable to parse the transaction you sent'):
        return new Errors.UNABLE_TO_PARSE_TX();
      case errMsg.includes(
        'The transaction you sent does not have any output to the bitcoin address on the invoice'
      ):
        return new Errors.WRONG_ADDRESS();
      case errMsg.includes('The amount on the transaction (X BTC) does'):
        return new Errors.WRONG_AMOUNT();
      case errMsg.includes('Transaction fee (X sat/kb) is below'):
        return new Errors.NOT_ENOUGH_FEE();
      case errMsg.includes('This invoice is priced in BTC, not BCH.'):
        return new Errors.BTC_NOT_BCH();
      case errMsg.includes(
        '	One or more input transactions for your transaction were not found on the blockchain.'
      ):
        return new Errors.INPUT_NOT_FOUND();
      case errMsg.includes(
        'The PayPro request has timed out. Please connect to the internet or try again later.'
      ):
        return new Errors.REQUEST_TIMEOUT();
      case errMsg.includes(
        'One or more input transactions for your transactions are not yet confirmed in at least one block.'
      ):
        return new Errors.UNCONFIRMED_INPUTS_NOT_ACCEPTED();
      default:
        return new Error(errMsg);
    }
  }

  /**
   * Makes a request to the given url and returns the raw JSON string retrieved as well as the headers
   * @param {string} paymentUrl the payment protocol specific url
   * @param {boolean} unsafeBypassValidation bypasses signature verification on the request (DO NOT USE IN PRODUCTION)
   */
  static async getPaymentOptions({
    paymentUrl,
    unsafeBypassValidation = false
  }) {
    const paymentUrlObject = url.parse(paymentUrl);

    // Detect 'bitcoin:' urls and extract payment-protocol section
    if (
      paymentUrlObject.protocol !== 'http:' &&
      paymentUrlObject.protocol !== 'https:'
    ) {
      const uriQuery = query.decode(paymentUrlObject.query);
      if (!uriQuery.r) {
        throw new Error('Invalid payment protocol url');
      } else {
        paymentUrl = uriQuery.r;
      }
    }

    const { rawBody, headers } = await PayProV2._asyncRequest({
      method: 'get',
      url: paymentUrl,
      headers: {
        Accept: 'application/payment-options',
        'x-paypro-version': 2,
        Connection: 'Keep-Alive',
        'Keep-Alive': 'timeout=30, max=10'
      }
    });

    return await this.verifyResponse(
      paymentUrl,
      rawBody,
      headers,
      unsafeBypassValidation
    );
  }

  /**
   * Selects which chain and currency option the user will be using for payment
   * @return {Promise<{payProDetails: Object}>}
   */
  static async selectPaymentOption(args: {
    /** The payment protocol specific url */
    paymentUrl: string;
    chain?: string;
    currency?: string;
    payload?: any;
    unsafeBypassValidation?: boolean;
  }) {
    const { paymentUrl, chain, payload, unsafeBypassValidation } = args;
    let { currency } = args;
    if (currency === 'USDP') currency = 'PAX'; // TODO workaround. Remove this when usdp is accepted as an option
    const { rawBody, headers } = await PayProV2._asyncRequest({
      url: paymentUrl,
      method: 'post',
      headers: {
        'Content-Type': 'application/payment-request',
        'x-paypro-version': 2,
        Connection: 'Keep-Alive',
        'Keep-Alive': 'timeout=30, max=10'
      },
      args: JSON.stringify({
        chain: chain?.toUpperCase(),
        currency,
        payload
      })
    });

    return await PayProV2.verifyResponse(
      paymentUrl,
      rawBody,
      headers,
      unsafeBypassValidation
    );
  }

  /**
   * Sends an unsigned raw transaction to the server for verification of outputs and fee amount
   * @param {string} paymentUrl - the payment protocol specific url
   * @param {string} chain - The cryptocurrency chain of the payment (BTC, BCH, ETH, etc)
   * @param {string} currency - When spending a token on top of a chain, such as GUSD on ETH this would be GUSD,
   * if no token is used this should be blank
   * @param [{tx: string, weightedSize: number}] unsignedTransactions - Hexadecimal format unsigned transactions
   * @param {boolean} unsafeBypassValidation
   * @return {Promise<{payProDetails: Object}>}
   */
  static async verifyUnsignedPayment({
    paymentUrl,
    chain,
    currency,
    unsignedTransactions,
    unsafeBypassValidation = false
  }) {
    if (currency === 'USDP') currency = 'PAX'; // TODO workaround. Remove this when usdp is accepted as an option
    const { rawBody, headers } = await PayProV2._asyncRequest({
      url: paymentUrl,
      method: 'post',
      headers: {
        'Content-Type': 'application/payment-verification',
        'x-paypro-version': 2,
        Connection: 'Keep-Alive',
        'Keep-Alive': 'timeout=30, max=10'
      },
      args: JSON.stringify({
        chain: chain?.toUpperCase(),
        currency,
        transactions: unsignedTransactions
      })
    });

    return await this.verifyResponse(
      paymentUrl,
      rawBody,
      headers,
      unsafeBypassValidation
    );
  }

  /**
   * Sends a signed transaction as the final step for payment
   * @param {string} paymentUrl the payment protocol specific url
   * @param {string} chain
   * @param {string} currency
   * @param {[string]} signedTransactions
   * @param {number} weightedSize
   * @param {boolean} unsafeBypassValidation
   * @return {Promise<Promise<{ payProDetails: Object}>}
   */
  static async sendSignedPayment({
    paymentUrl,
    chain,
    currency,
    signedTransactions,
    unsafeBypassValidation = false,
    bpPartner
  }) {
    if (currency === 'USDP') currency = 'PAX'; // TODO workaround. Remove this when usdp is accepted as an option
    const { rawBody, headers } = await this._asyncRequest({
      url: paymentUrl,
      method: 'post',
      headers: {
        'Content-Type': 'application/payment',
        'x-paypro-version': 2,
        BP_PARTNER: bpPartner.bp_partner,
        BP_PARTNER_VERSION: bpPartner.bp_partner_version,
        Connection: 'Keep-Alive',
        'Keep-Alive': 'timeout=30, max=10'
      },
      args: JSON.stringify({
        chain: chain?.toUpperCase(),
        currency,
        transactions: signedTransactions
      })
    });

    return await this.verifyResponse(
      paymentUrl,
      rawBody,
      headers,
      unsafeBypassValidation
    );
  }

  /**
   * Verifies the signature on any response from the payment requestor
   * @param {String} requestUrl - Url which the request was made to
   * @param {String} rawBody - The raw string body of the response
   * @param {Object} headers -
   * @param {Boolean} unsafeBypassValidation
   * @return {Promise<{ payProDetails: Object}>}
   */
  static async verifyResponse(
    requestUrl,
    rawBody,
    headers,
    unsafeBypassValidation
  ) {
    if (!requestUrl) {
      throw new Error('Parameter requestUrl is required');
    }
    if (!rawBody) {
      throw new Error('Parameter rawBody is required');
    }
    if (!headers) {
      throw new Error('Parameter headers is required');
    }

    let responseData;
    try {
      responseData = JSON.parse(rawBody);
    } catch {
      throw new Error('Invalid JSON in response body');
    }

    const payProDetails = this.processResponse(responseData);

    if (unsafeBypassValidation) {
      return payProDetails;
    }

    const hash = headers.digest.split('=')[1];
    const signature = headers.signature;
    const signatureType = headers['x-signature-type'];
    const identity = headers['x-identity'];
    let host;

    try {
      host = url.parse(requestUrl).hostname;
    } catch {/** no op */}

    if (!host) {
      throw new Error('Invalid requestUrl');
    }
    if (!signatureType) {
      throw new Error('Response missing x-signature-type header');
    }
    if (typeof signatureType !== 'string') {
      throw new Error('Invalid x-signature-type header');
    }
    if (signatureType !== 'ecc') {
      throw new Error(`Unknown signature type ${signatureType}`);
    }
    if (!signature) {
      throw new Error('Response missing signature header');
    }
    if (typeof signature !== 'string') {
      throw new Error('Invalid signature header');
    }
    if (!identity) {
      throw new Error('Response missing x-identity header');
    }
    if (typeof identity !== 'string') {
      throw new Error('Invalid identity header');
    }

    if (!PayProV2.trustedKeys[identity]) {
      throw new Error(
        `Response signed by unknown key (${identity}), unable to validate`
      );
    }

    const keyData = PayProV2.trustedKeys[identity];
    const actualHash = sha256(Buffer.from(rawBody, 'utf8')).toString('hex');
    if (hash !== actualHash) {
      throw new Error(
        `Response body hash does not match digest header. Actual: ${actualHash} Expected: ${hash}`
      );
    }

    if (!keyData.domains.includes(host)) {
      throw new Error(
        `The key on the response (${identity}) is not trusted for domain ${host}`
      );
    }

    const hashbuf = Buffer.from(hash, 'hex');
    const sigbuf = Buffer.from(signature, 'hex');

    const s_r = BN.fromBuffer(sigbuf.slice(0, 32));
    const s_s = BN.fromBuffer(sigbuf.slice(32));

    const pub = Bitcore.PublicKey.fromString(keyData.publicKey);
    const sig = new Bitcore.crypto.Signature(s_r, s_s);
    const valid = Bitcore.crypto.ECDSA.verify(hashbuf, sig, pub);

    if (!valid) {
      throw new Error('Response signature invalid');
    }

    return payProDetails;
  }

  /**
   * Internal method for processing response
   * @param {Object} responseData
   * @return {Promise<Object{payProDetails: Object}>}
   * @private
   */

  static processResponse(responseData) {
    const payProDetails: any = {
      paymentId: responseData.paymentId,
      payProUrl: responseData.paymentUrl,
      memo: responseData.memo
    };

    // otherwise, it returns err.
    payProDetails.verified = true;

    if (responseData.paymentOptions) {
      payProDetails.paymentOptions = responseData.paymentOptions;
      for (const option of payProDetails.paymentOptions) {
        option.network = NetworkMap[option.network];
      }
    }

    if (responseData.network) {
      payProDetails.network = NetworkMap[responseData.network];
    }

    if (responseData.chain) {
      payProDetails.chain = responseData.chain?.toLowerCase();
    }

    if (responseData.currency) {
      payProDetails.currency = responseData.currency;
    }

    if (responseData.expires) {
      try {
        payProDetails.expires = new Date(responseData.expires).toISOString();
      } catch {
        throw new Error('Bad expiration');
      }
    }

    if (responseData.time) {
      try {
        payProDetails.time = new Date(responseData.time).toISOString();
      } catch {
        throw new Error('Bad time');
      }
    }

    if (responseData.instructions) {
      payProDetails.instructions = responseData.instructions;
      for (const output of payProDetails.instructions) {
        output.toAddress = output.to || output.outputs[0].address;
        output.amount =
          output.value !== undefined ? output.value : output.outputs[0].amount;
      }
      const { requiredFeeRate, gasPrice } = responseData.instructions[0];
      payProDetails.requiredFeeRate = requiredFeeRate || gasPrice;

      if (payProDetails.requiredFeeRate) {
        if (
          payProDetails.requiredFeeRate > MAX_FEE_PER_KB[payProDetails.chain]
        ) {
          throw new Error('Fee rate too high:' + payProDetails.requiredFeeRate);
        }
      }
    }
    return payProDetails;
  }
};
